/**
 * Copyright @2016-present, Sumscope, Inc.
 * All rights reserved.
 */

const http = require('http');
const socketIO = require('socket.io');
const compose = require('koa-compose');
const Socket = require('./socket');
const { concat } = require('../../util');

const noop = () => {};

module.exports = class {
  constructor(opts) {
    if (opts && !(typeof opts !== 'string' || opts && typeof opts !== 'object')) {
      throw new Error('Incorrect argument passed to koaSocket constructor')
    }
    
    this._middlewares = [];
    this._composed = null;
    this._emitFilters = [];
    this._listeners = new Map();
    this.connections = new Map();
    this._opts = opts;
    this.socket = null;
    this.userSockets = new Map();
    this._sessionMiddleware = null;
    this.findUser = (sock) => sock.session.user;
    // Bind handlers
    this._onConnection = this._onConnection.bind(this);
    this._onDisconnect = this._onDisconnect.bind(this);
    
    opts.filterTopic && this.filterTopic();
  }
  
  /**
   * Attach to a koa application
   */
  attach(app) {
    if (app.server && app.server.constructor.name != 'Server') {
      throw new Error('app.server already exists but it\'s not an http server');
    }
    
    if (!app.server) {
      // Create a server if it doesn't already exists
      app.server = http.createServer(app.callback());
      // Patch `app.listen()` to call `app.server.listen()`
      app.listen = (port, callback) => {
        app.server.listen.call(app.server, port, callback);
        return app.server
      }
    }
    app._io = socketIO(app.server, this._opts.ioOptions);
    app.io = this;
    
    // If there is no namespace then connect using the default
    this.socket = app._io;
    this.socket.on('connection', this._onConnection);
    this._opts.cluster && this.adapter(require('./socket-adapter'));
  }
  
  session(sessionMiddleware) {
    this._sessionMiddleware = sessionMiddleware;
    return this;
  }
  
  adapter(adapter) {
    this.socket.adapter(adapter);
    return this;
  }
  
  /**
   * Pushes a middleware on to the stack
   */
  use(middleware) {
    this._middlewares.push(middleware);
    this._composed = compose(this._middlewares);
    this._updateConnections();
    return this;
  }
  
  filterTopic() {
    this.onEmit((ctx) => ctx.socket.topics && ctx.socket.topics.has(ctx.event));
    this.on('register', (ctx) => {
      if (!ctx.socket.topics) {
        ctx.socket.topics = new Set();
      }
      ctx.socket.topics.add(ctx.data);
    });
  }
  
  /**
   * Adds a new listeners to the stack
   */
  on(event, handler) {
    let handlers = this._listeners.get(event);
    if (!handlers) {
      handlers = new Set();
      this._listeners.set(event, handlers);
    }
    handlers.add(handler);
    this._updateConnections();
    return this;
  }
  
  /**
   * Removes a listener from the event
   */
  off(event, handler) {
    if (!event) {
      this._listeners = new Map();
      this._updateConnections();
      return this;
    }
    
    if (!handler) {
      this._listeners.delete(event);
      this._updateConnections();
      return this;
    }
    
    let handlers = this._listeners.get(event);
    if (handlers) {
      handlers.delete(handler);
    }
    return this;
  }
  
  onEmit(middleware) {
    this._emitFilters.push(middleware);
    this._updateConnections();
    return this;
  }
  
  /**
   * Broadcasts an event to all connections
   */
  broadcast(event, data) {
    this.connections.forEach((socket, id) => socket.emit(event, data));
  }
  
  /**
   * Emit data to the specified user
   */
  emitUser(user, channel, data) {
    if (this.userSockets.size == 0) return;
    if (user === 'all') {
      let sockets = [...this.userSockets.values()]
        .reduce((left, right) => concat(left, right), []);
      this.emit(sockets, channel, data);
    } else {
      this.emit(this.userSockets.get(user), channel, data);
    }
  }
  
  /**
   * Emit data to the specified clients
   */
  emit(ids, channel, data) {
    if (!ids) return;
    if (typeof ids === 'string') {
      const socket = this.connections.get(ids);
      socket && socket.emit(channel, data);
    } else {
      ids.forEach(id => {
        const socket = this.connections.get(id);
        socket && socket.emit(channel, data);
      });
    }
  }
  
  /**
   * Triggered for each new connection
   * Creates a new Socket instance and adds that to the stack and sets up the
   * disconnect event
   */
  _onConnection(sock) {
    (async function () {
      let session = null;
      if (this._sessionMiddleware) {
        session = await this._sessionMiddleware(sock, noop);
      }
      delete session.cookie;
      let instance = new Socket(sock, this._listeners, this._composed, this._emitFilters, session);
      this.connections.set(sock.id, instance);
      this._updateUserSocket(instance);
      sock.on('disconnect', () => {
        this._onDisconnect(instance);
      });
    }.bind(this))();
  }
  
  _updateUserSocket(sock) {
    let user = this.findUser(sock);
    if (!user) return;
    let sockets = this.userSockets.get(user);
    if (!sockets) {
      sockets = new Set();
      this.userSockets.set(user, sockets);
    }
    sockets.add(sock.id);
  }
  
  /**
   * Fired when the socket disconnects, simply reflects stack in the connections stack
   */
  _onDisconnect(sock) {
    this.connections.delete(sock.id);
    // remove from user socket map
    let user = this.findUser(sock);
    if (!user) return;
    let sockets = this.userSockets.get(user);
    if (!sockets) return;
    sockets.delete(sock.id);
    if (sockets.size === 0) {
      this.userSockets.delete(user);
    }
  }
  
  /**
   * Updates all existing connections with current listeners and middleware
   */
  _updateConnections() {
    this.connections.forEach(connection => {
      connection.update(this._listeners, this._composed, this._emitFilters);
    })
  }
};
